Skip to content

feat(deleter): clear or report org delete blockers up front - #1857

Open
whoAbhishekSah wants to merge 1 commit into
org-delete-preflight-mocksfrom
org-delete-preflight-blockers
Open

feat(deleter): clear or report org delete blockers up front#1857
whoAbhishekSah wants to merge 1 commit into
org-delete-preflight-mocksfrom
org-delete-preflight-blockers

Conversation

@whoAbhishekSah

@whoAbhishekSah whoAbhishekSah commented Aug 5, 2026

Copy link
Copy Markdown
Member

Closes #1837. Based on #1865; the forfeit email (#1880) and the check RPC (#1893/#1894) stack on top.

DeleteOrganization now checks everything that blocks the delete before touching any data, and returns every reason together as one failed_precondition response with a PreconditionFailure detail (one violation per blocker). The proto ack field from the issue text was dropped (raystack/proton#497 closed) — unused tokens never block.

What blocks the delete:

  • Active subscription on a paid plan (ACTIVE_SUBSCRIPTION): any running subscription whose plan has a priced product. The caller downgrades it to the standard plan through the normal plan-change flow, then retries. Plans resolve lazily — only when a running subscription references one — so a broken plan reference can only affect orgs that actually hold such a subscription.
  • Invoice that still asks for money (UNPAID_INVOICE): open and uncollectible invoices the caller can pay, and drafts — the provider finalizes those shortly, and deleting inside that window would silently lose the charge, so they block until payable. "Asks for money" is judged by the provider's amount_remaining, so a negative-total credit note or an invoice fully covered by the customer's credit balance never blocks.
  • A running subscription on a plan that no longer resolves blocks with its own message (cancel the subscription, then retry) instead of failing the delete forever.
  • Negative token balance (NEGATIVE_TOKEN_BALANCE): the account owes tokens (overdraft). Support settles it.

What does not block:

  • A running subscription on a free plan: once nothing else blocks, the delete cancels it itself (immediate, unbilled usage invoiced on the spot), then re-checks the invoices. Every account is judged again before anything is canceled, so a paid subscription created mid-delete blocks without costing another account its subscription first, and a subscription whose provider copy is already gone is skipped rather than failing the delete forever. Paid subscriptions are never canceled by the delete, so a blocked delete never costs the caller a plan they pay for.
  • Unused tokens: the delete forfeits them (the client confirms with the user first) and writes the amount to an app.billing.tokens.forfeited audit record. feat(deleter): email the org owners when a delete forfeits tokens #1880 adds the owner email.

Other changes:

  • Invoices are judged straight from the billing provider through a new invoice.ListPayableOnProvider — three status-filtered pages, no expansion, no local writes — instead of a full SyncWithProvider, keeping the delete's latency independent of the org's invoice history. An invoice the sync has not stored yet is reported by its provider reference.
  • The billing accounts are listed once per delete and passed through the whole flow, and plan lookups share one per-delete cache.
  • Deleting an org that is already gone returns not_found before any checks run. Disabled orgs stay deletable.
  • New invoice state constant UncollectibleState; first use of errdetails in the repo (google.golang.org/genproto/googleapis/rpc becomes a direct dependency).

🤖 Generated with Claude Code

@vercel

vercel Bot commented Aug 5, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview Aug 21, 2026 8:16am

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 43f3d53f-8b53-4d39-ac45-b16fd2703499

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features
    • Organization deletion now checks for active subscriptions, unpaid invoices, and token balances before making changes.
    • Deletion responses clearly identify all conditions preventing completion.
    • Positive token balances can be forfeited when explicitly acknowledged.
    • Billing-account deletion records forfeited tokens for audit purposes.
  • Bug Fixes
    • Improved handling and reporting of billing-related deletion failures.
  • API Updates
    • Added support for the “uncollectible” invoice status.

Walkthrough

Organization deletion now performs billing preflight checks, aggregates structured blockers, and accepts token-forfeit acknowledgement. Billing-account deletion audits forfeited tokens. The Connect API returns structured failed-precondition details for blocked deletions.

Changes

Organization deletion workflow

Layer / File(s) Summary
Deletion contracts and billing events
billing/invoice/invoice.go, core/audit/audit.go, core/deleter/deleter.go, core/deleter/service.go, core/deleter/mocks/*
Added the uncollectible invoice state, token-forfeiture system event, structured deletion blockers, and subscription and balance service contracts with generated mocks.
Deletion preflight and token forfeiture
core/deleter/service.go, core/deleter/service_test.go
Organization deletion aggregates subscription, invoice, and balance blockers before modifying data. Billing-account deletion audits positive token balances before deleting credit transactions.
Connect API propagation and validation
internal/api/v1beta1connect/deleter.go, internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/mocks/*, internal/api/v1beta1connect/deleter_test.go, go.mod, Makefile
The API forwards token-forfeit acknowledgement and maps blocked deletions to structured FailedPrecondition responses. RPC dependencies and the protobuf source commit were updated.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

Possibly related PRs

  • raystack/frontier#1838 — Also changes organization-deletion behavior and core/deleter/service.go.
  • raystack/frontier#1839 — Also changes billing-related deletion audit handling in core/audit/audit.go and core/deleter/service.go.

Suggested reviewers: rohilsurana

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches 💡 1
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch org-delete-preflight-blockers

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 51d78c26-5a73-43e9-8050-b9aec5ac46cb

📥 Commits

Reviewing files that changed from the base of the PR and between c294c5f and 783da24.

⛔ Files ignored due to path filters (1)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
📒 Files selected for processing (13)
  • Makefile
  • billing/invoice/invoice.go
  • core/audit/audit.go
  • core/deleter/deleter.go
  • core/deleter/mocks/credit_service.go
  • core/deleter/mocks/subscription_service.go
  • core/deleter/service.go
  • core/deleter/service_test.go
  • go.mod
  • internal/api/v1beta1connect/deleter.go
  • internal/api/v1beta1connect/deleter_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/cascade_deleter.go

Comment thread core/deleter/service_test.go Outdated
@whoAbhishekSah
whoAbhishekSah force-pushed the org-delete-preflight-blockers branch from 783da24 to 3c17128 Compare August 10, 2026 06:28
@whoAbhishekSah
whoAbhishekSah force-pushed the org-delete-preflight-blockers branch from 3c17128 to 969ef16 Compare August 13, 2026 10:57
@whoAbhishekSah whoAbhishekSah changed the title feat(deleter): check all org delete blockers up front and return them together feat(deleter): clear or report org delete blockers up front Aug 13, 2026
@coveralls

coveralls commented Aug 13, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 32462377017

Coverage increased (+0.1%) to 48.912%

Details

  • Coverage increased (+0.1%) from the base build.
  • Patch coverage: 78 uncovered changes across 6 files (171 of 249 lines covered, 68.67%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
billing/invoice/service.go 37 0 0.0%
core/deleter/service.go 178 148 83.15%
core/deleter/deleter.go 6 0 0.0%
billing/invoice/invoice.go 3 0 0.0%
cmd/serve.go 1 0 0.0%
internal/api/v1beta1connect/deleter.go 24 23 95.83%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
core/deleter/service.go 1 79.51%

Coverage Stats

Coverage Status
Relevant Lines: 40332
Covered Lines: 19727
Line Coverage: 48.91%
Coverage Strength: 15.71 hits per line

💛 - Coveralls

Comment thread core/deleter/deleter.go Outdated
Comment thread core/deleter/deleter.go Outdated
Comment thread core/deleter/service.go Outdated

@whoAbhishekSah whoAbhishekSah left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the deleter changes; 10 inline notes below. The two biggest for me: provider-drifted subs making an org permanently undeletable, and draft invoices slipping through the blocker check and getting discarded by the customer delete. None of these are fixed by the later commits in the stack.

Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go
Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go
Comment thread core/deleter/service.go
Comment thread core/deleter/service.go

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stack-aware review (base org-delete-preflight-mocks). A few things to flag. The invoice one is the most important; the rest are smaller. I dropped two earlier notes after reading the stack: the empty-PlanID-is-paid case is a documented choice, and the mid-teardown balance read is reworked in #1880.

Comment thread billing/invoice/service.go Outdated
Comment thread core/deleter/service.go
Comment thread core/deleter/service.go
Comment thread core/deleter/service_test.go Outdated
@whoAbhishekSah

Copy link
Copy Markdown
Member Author

Round 3 sandbox results — retesting the behavior changes from the review (stack: #1857#1880#1894, tested at the tip org-delete-check-api @ 6b9468e). Each case used a fresh org, a real Stripe test customer, and a normal org-owner session.

1. Credit-note invoice no longer blocks. Draft invoice with total −500 (amount_remaining 0). CheckOrganizationDelete returned can_delete: true and the delete succeeded. The old total-based check would have blocked it.

2. Credit-balance-covered invoice no longer blocks. Customer with a 1000 credit balance and a draft invoice of total 800 (amount_due 0 on Stripe). Check passed and the delete succeeded.

3. Dangling plan yields a blocker, not an error. Subscription row pointing at a plan id that no longer exists (a real Stripe sub behind it). Check and delete both returned the failed_precondition blocker: "subscription[…] is active on a plan that no longer exists: cancel the subscription, then retry the delete". The advice works: CancelSubscription succeeded despite the dangling plan (canceled on Stripe too), and the retry deleted the org.

4. Cancel pass is two-phase. One org, two billing accounts: A with an active free-plan subscription, B with an open unpaid invoice. The delete blocked on B's invoice and A's subscription stayed active on Stripe and in the local rows. After voiding the invoice, the retry deleted the org and canceled A's subscription itself. Both Stripe customers were removed.

5. Retry recovers the forfeit notice from audit. Org with zero live balance plus an app.billing.tokens.forfeited audit row (amount 750, purchased 200), the state a failed first attempt leaves behind. The retry delete succeeded and the owner received the notice rebuilt from the audit row: "Your organization Round3 r3-retry was deleted by delowner3@raystack.org with 750 unused tokens remaining, of which 200 came from purchases."

All test orgs, billing accounts, and Stripe customers were cleaned up afterwards.

🤖 Generated with Claude Code

@rohilsurana rohilsurana left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-review after the fix commits. The earlier points are handled well: AmountRemaining for the filter, the unresolvablePlanBlocker for dangling plans, the judge-before-cancel restructure, and the test comment. A few new things the fixes surfaced, plus two small doc nits.

Comment thread billing/invoice/service.go
Comment thread core/deleter/service.go Outdated
Comment thread core/deleter/service.go
Comment thread billing/invoice/service.go Outdated
Comment thread core/deleter/service.go
Comment thread billing/invoice/service.go Outdated
The delete first checks everything that blocks it and returns all the
reasons together as one failed_precondition response: a running
subscription on a paid plan (the caller downgrades it to the standard
plan), invoices that still ask for money, and a negative token balance
which support has to settle. Plans resolve lazily, only when a running
subscription references one. When nothing blocks, subscriptions still
running on a free plan are canceled immediately with unbilled usage
invoiced on the spot — tolerating copies already gone on the provider —
and the invoice check runs again so a final invoice still blocks. The
plan is judged again in that pass, so a paid subscription created
mid-delete blocks instead of being canceled. Unused tokens do not
block: the delete forfeits them and writes the amount to an audit
record. An already-deleted org returns not found before any checks run.

Invoices are judged straight from the billing provider through a new
cheap ListPayableOnProvider (three status-filtered pages, no local
writes) instead of a full sync, and drafts with a non-zero amount now
block too: the provider finalizes them shortly, and deleting inside
that window would silently lose the charge.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Organization delete: check all blockers up front and tell the user what to fix

3 participants